【C++】右值引用和移动语义 | 您所在的位置:网站首页 › foreach 引用赋值 › 【C++】右值引用和移动语义 |
在C++中,如果一个类获取了资源,则需要定义拷贝构造函数和拷贝赋值运算符以确保资源被正确地拷贝。然而,在某些情况下会存在不必要的拷贝,影响程序性能。为了解决这一问题,C++11引入了移动语义。本文首先介绍C++的左值和右值及其引用,之后介绍移动语义及其实现。 1.左值和右值在C++中,每个表达式或者是左值,或者是右值。 左值(lvalue):可以出现在赋值表达式左侧的值,例如变量名a、数据成员a.m、下标表达式a[n]、解引用表达式*p等。左值可以被赋值和取地址。右值(rvalue):只能出现在赋值表达式右侧的值,例如字面值42、算术表达式a+b、临时对象Point(3,4)、返回值是值类型的函数调用等。右值不能被赋值和取地址。例如: int a; int* p = &a; // OK, a is lvalue *p = 42; // OK, *p is lvalue p = &42; // error, 42 is rvalue a + 1 = *p; // error, a + 1 is rvalue注:实际上C++标准定义了纯右值(prvalue)、将亡值(xvalue)和左值三个类别,纯右值和将亡值统称为右值,详见Value categories - cppreference。 2.左值引用和右值引用C++的引用(reference)是一种类型,可以看作对象的别名。引用在本质上和指针一样,都是对象的地址(指针和引用的区别详见《C++程序设计原理与实践》笔记 第17章 17.9节)。 C++提供了两种类型的引用: 左值引用(lvalue reference):使用&表示,T&是T类型的左值引用。左值引用是最常用的引用类型,可用于在函数调用中实现传引用(pass-by-reference)语义。右值引用(rvalue reference):使用&&表示,T&&是T类型的右值引用。右值引用是C++11引入的,用于实现移动语义(见第3节)。注:左值和右值是表达式的一种属性/分类,而左值引用和右值引用是两种不同的类型,二者是完全不同的概念,但是存在一定的联系: 左值引用必须使用左值初始化(即左值引用只能绑定到左值),一个例外是const左值引用可以使用右值初始化;右值引用必须使用右值初始化。如果函数的返回类型是左值引用(例如vector::operator[]),则函数调用表达式是左值;如果函数的返回类型是右值引用(例如std::move())或者不是引用(例如vector::size()),则函数调用表达式是右值。左值引用和有名字的右值引用都是左值(这意味着有名字的右值引用可以被赋值和取地址)。例如: int a; int& lr = a; int* p = &lr; // OK, lr is lvalue lr = 42; // OK, lr is lvalue int& lr2 = 42; // error, lvalue reference can't bind to rvalue const int& clr = 42; // OK, const lvalue reference bind to rvalue const int* cp = &clr; // OK, clr is lvalue int&& rr = a + 1; p = &rr; // OK, rr is lvalue ++rr; // OK, rr is lvalue int&& rr2 = a; // error, rvalue reference can't bind to lvalue int&& rr3 = rr; // error, rvalue reference can't bind to lvalue int&& rr4 = std::move(a); // OK, std::move(a) is rvalue其中,std::move()函数将左值转换为右值引用,详见3.4节。 3.移动语义为了在特定情况下避免不必要的拷贝,C++11引入了移动语义。在介绍移动语义之前,下面通过一个vector的例子说明什么情况下存在不必要的拷贝,之后介绍如何实现移动语义。 3.1 拥有资源的类一个类可能会获取资源,例如自由存储(使用new创建的对象或数组)、文件、锁、线程、套接字等,这样的类通常具有指向资源的指针成员。 标准库vector是一个典型的拥有资源的类的例子。例如: vector age = {0.33, 22.0, 27.2, 54.2};下图是(简化的)age示意图: 其中,存储元素的数组是使用new在自由存储上分配的,age对象本身仅保存了元素个数和指向该数组的指针。 拥有资源的类通常需要拷贝构造函数、拷贝赋值运算符和析构函数,以确保 当对象被拷贝时,资源被正确拷贝。当对象被销毁时,资源被正确释放。否则可能会导致内存泄露、重复释放等问题,因为拷贝的默认含义是“拷贝所有数据成员”(即浅拷贝)。关于这一点,详见《C++程序设计原理与实践》笔记 第18章 18.3.1和18.3.2节,这里不再详细介绍。 simple_vector.h给出了一个简化的vector实现,并且定义了拷贝构造函数和拷贝赋值运算符。 然而,在某些情况下会存在不必要的拷贝。下面借用《C++程序设计原理与实践》第18章中的例子: vector fill(istream& is) { vector res; for (double x; is >> x;) res.push_back(x); return res; } void use() { vector vec = fill(cin); // ... use vec ... }由于函数fill()的返回类型是值类型,因此理论上会发生两次拷贝(res→返回值临时对象→vec)。假设res有10万个元素,则拷贝代价是很高的。但实际上,use()永远不会使用res,因为res在函数fill()返回后就会被销毁,因此从res到vec的拷贝就是不必要的——可以设法让vec直接复用res的资源。 为了解决这一问题,C++11引入了移动语义(move semantics):通过“窃取”资源,直接将res的资源移动(move)到vec,如下图所示: 移动之后,vec将引用res的元素,而res将被置空(换句话说,移动 = “窃取”资源 = 浅拷贝+置空原指针)。 总之,移动语义是为了解决由即将被销毁的对象初始化或赋给其他对象时发生不必要的拷贝,通过“窃取”资源(移动)来避免拷贝。 注: 即使没有移动语义,这里的拷贝操作也可能被编译器的拷贝消除特性优化掉,但实际效果取决于具体编译器、使用的C++标准版本以及编译选项等,详见3.5节的示例。而移动语义可以保证得到一致的结果。除了使用移动语义,还有两种替代方法:(1)传引用参数: void fill(istream& is, vector& v) { for (double x; is >> x;) v.push_back(x); } void use() { vector vec; fill(cin, vec); // ... use vec ... }缺点是不能使用返回值语法,必须先声明变量。 (2)返回new创建的指针: vector* fill(istream& is) { vector* res = new vector; for (double x; is >> x;) res->push_back(x); return res; } void use() { vector* vec = fill(cin); // ... use vec ... delete vec; }缺点是必须记得delete这个向量。 我们希望使用返回值语法,同时避免拷贝。移动语义可以做到这一点。 3.2 移动构造函数和移动赋值为了在C++中表达移动语义,需要定义移动构造函数(move constructor)和移动赋值(move assignment)运算符: T(T&& v); // move constructor T& operator=(T&& v); // move assignment移动构造函数和移动赋值运算符的参数都是右值引用,因为右值正是前面提到的“即将被销毁的对象”。 当使用一个右值初始化一个相同类型的对象时,移动构造函数将被调用。 包括: 初始化:T a = std::move(b);或T a(std::move(b));,其中b是T类型函数参数传递:f(std::move(a)),其中a和函数参数都是T类型函数返回值:return a;,其中函数返回值是T类型,且T有移动构造函数注:如果初始值是纯右值(prvalue),则移动构造函数调用可能会被拷贝消除优化掉,详见3.3节。 当对象出现在赋值表达式左侧,并且右侧是一个相同类型的右值时,移动赋值运算符将被调用。 simple_vector.cpp为简化的vector定义了移动构造函数和移动赋值运算符。 再次考虑前面的例子,在fill()返回时,vector的移动构造函数将被隐式调用(fill()和use()的代码均不需要修改)。 3.3 拷贝消除C++标准支持拷贝消除(copy elision),允许编译器在某些情况下省略拷贝构造函数和移动构造函数的调用,从而提高程序的性能。拷贝消除的规则也随着C++版本的更新而不断扩展。 从C++17开始,在下列情况下编译器会强制进行拷贝消除: 在return语句中,操作数是与返回类型相同的纯右值。例如,T f() { return T(); }在对象初始化中,初始值是相同类型的纯右值。例如,T x = T();在下列情况下,编译器允许但不强制进行拷贝消除: 在return语句中,操作数是与返回类型相同的变量的名字,但不能是函数参数。这一规则称为命名返回值优化(named return value optimization, NRVO)。例如,T f() { T x; return x; }在对象初始化中,源对象是一个相同类型的无名的临时对象。当这个临时对象来自return语句时,这一规则称为返回值优化(return value optimization, RVO)。例如,T x = f();注:上面仅列出了常见情况,完整规则详见Copy elision - cppreference。 当拷贝消除发生时,被省略的拷贝/移动构造函数的源对象(参数)和目标对象(this)将引用同一个对象。 3.4 std::move前面提到,右值引用不能绑定到左值,因此左值不能被移动。但是,标准库头文件提供了std::move()函数,作用是将参数转换为右值引用,即将参数“当作”右值,使其变成“可移动的”。 虽然std::move()的返回类型是右值引用,但调用该函数的表达式是一个右值(准确来说是xvalue)。如果a是一个左值,则std::move(a)是一个右值,这意味着该对象被认为是“可移动的”(可能被窃取资源),因此不能再使用。例如: std::vector a = {1, 2, 3}; std::vector b = std::move(a); std::cout } C(const C& c) { std::cout std::cout C c; return c; } int main() { C a = f(); C b = a; a = C(); b = a; b = std::move(a); return 0; }输出如下: move constructor move constructor copy constructor move assignment copy assignment move assignment return c;调用移动构造函数(将局部变量c移动到返回值临时对象),因为函数f()的返回类型C不是引用类型,且C有移动构造函数调用移动构造函数(将返回值临时对象移动到a),因为f()是一个右值C b = a;调用拷贝构造函数,因为a是一个左值a = C();调用移动赋值,因为C()是一个右值,且C有移动赋值b = a;调用拷贝赋值,因为a是一个左值b = std::move(a);调用移动赋值,因为std::move(a)是一个右值,且C有移动赋值注: C a = f();涉及的两次移动构造函数调用可能会被编译器的拷贝消除特性优化掉,从而c和a的地址是一样的,整个语句只有一次默认构造函数调用。使用不同的C++标准版本和编译选项的情况下,C a = f();调用移动构造函数的次数如下表所示(使用的编译器是GCC 13): C++标准版本编译选项移动构造函数调用次数C++11-fno-elide-constructors2 (c→返回值临时对象→a)C++11无0 (&c == &a)C++17-fno-elide-constructors1 (c→a)C++17无0 (&c == &a)如果C没有移动构造函数和移动复制,那么输出会变为 copy constructor copy constructor copy constructor copy assignment copy assignment copy assignment类似地,C a = f();涉及的两次拷贝构造函数调用可能会被编译器的拷贝消除特性优化掉: C++标准版本编译选项拷贝构造函数调用次数C++11-fno-elide-constructors2 (c→返回值临时对象→a)C++11无0 (&c == &a)C++17-fno-elide-constructors1 (c→a)C++17无0 (&c == &a)(由此看来,有了拷贝消除,移动语义似乎变得可有可无了) 除了移动语义,右值引用还有一个重要的用途——完美转发。 4.总结C++的值语义是万恶之源。 参考 Move constructor - cppreferenceMove assignment - cppreference【C++深陷】之“左值与右值”【C++深陷】之“对象移动”Understanding lvalues, rvalues and their references《C++程序设计原理与实践》笔记 第18章 |
CopyRight 2018-2019 实验室设备网 版权所有 |